面向对象的程序设计
- 理解对象属性
- 理解并创建对象
- 理解继承
1.1 理解对象
创建自定义对象的最简单方式就是创建一个Object的实例,然后再为它添加属性和方法1234567var person = new Object();person.name = "Nicholas";person.age = 29;person.job = "Software Engineer";person.sayName = function(){alert(this.name);};
现在对象字面量成为创建这种对象的首选模式
1.1.1 属性类型
ECMAScript中有两种属性:数据属性和访问器属性。
- 数据属性(数据属性包含一个数据值的位置。在这个位置可以读取和写入值。数据属性有 4 个描述其行为的
特性。) - [[Configurable]]:表示能否通过 delete 删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。像前面例子中那样直接在对象上定义的属性,它们的这个特性默认值为true。
- [[Enumerable]]:表示能否通过 for-in 循环返回属性。像前面例子中那样直接在对象上定义的属性,它们的这个特性默认值为 true。
- [[Writable]]:表示能否修改属性的值。像前面例子中那样直接在对象上定义的属性,它们的这个特性默认值为true。
- [[Value]]:包含这个属性的数据值。读取属性值的时候,从这个位置读;写入属性值的时候,把新值保存在这个位置。这个特性的默认值为 undefined。
对于像前面例子中那样直接在对象上定义的属性,它们的[[Configurable]]、[[Enumerable]]和[[Writable]]特性都被设置为 true,而[[Value]]特性被设置为指定的值.
要修改属性默认的特性,必须使用 ECMAScript5的Object.defineProperty()方法。这个方法接收三个参数:属性所在的对象、属性的名字和一个描述符对象。其中,描述符(descriptor)对象的属 性必须是:configurable、enumerable、writable和value。设置其中的一或多个值,可以修改对应的特性值。
- 访问器属性(访问器属性不包含数据值;它们包含一对儿 getter 和 setter 函数(不过,这两个函数都不是必需的)。在读取访问器属性时,会调用 getter函数,这个函数负责返回有效的值;在写入访问器属性时,会调用 setter 函数并传入新值,这个函数负责决定如何处理数据。访问器属性有如下 4 个特性。)
- [[Configurable]]:表示能否通过 delete 删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为数据属性。对于直接在对象上定义的属性,这个特性的默认值为 true。
- [[Enumerable]]:表示能否通过for-in 循环返回属性。对于直接在对象上定义的属性,这个特性的默认值为true。
- [[Get]]:在读取属性时调用的函数。默认值为 undefined。
- [[Set]]:在写入属性时调用的函数。默认值为 undefined。
访问器属性不能直接定义,必须使用 Object.defineProperty()来定义。
1.2 创建对象
虽然Object构造函数或对象字面量都可以用来创建单个对象,但这些方式有个明显的缺点:使用同一个接口创建很多对象,会产生大量的重复代码。为解决这个问题,人们开始使用工厂模式的一种变体。
1. 工厂模式
工厂模式是软件工程领域一种广为人知的设计模式,这种模式抽象了创建具体对象的过程,考虑到在ECMAScript中无法创建类,开发人员就发明了一种函数,用函数来封装以特定接口创建对象的细节。
函数createPerson()能够根据接受的参数来构建一个包含所有必要信息的Person对象。可以无数次地调用这个函数,而每次它都会返回一个包含三个属性一个方法的对象。工厂模式虽然解决了创建多个相似对象的问题,但却没有解决对象识别的问题(即怎样知道一个对象的类型)。随着 JavaScript 的发展,又一个新模式出现了。
2. 构造函数模式
可以使用构造函数模式将前面的例子重写
区别
- 没有显式地创建对象;
- 直接将属性和方法赋给了 this 对象;
- 没有 return 语句。
还应该注意到函数名Person使用的是大写字母P。按照惯例,构造函数始终都应该以一个大写字母开头,而非构造函数则应该以一个小写字母开头。这个做法借鉴自其他语言,主要是为了区别于ECMAScript中的其他函数;==因为构造函数本身也是函数,只不过可以用来创建对象而已。==
要创建Person的新实例,必须使用 new 操作符。以这种方式调用构造函数实际上会经历以下 4 个步骤:
- 创建一个新对象;
- 将构造函数的作用域赋给新对象(因此 this 就指向了这个新对象);
- 执行构造函数中的代码(为这个新对象添加属性);
- 返回新对象。
person1 和 person2分别保存着Person的一个不同的实例。这两个对象都有一个constructor(构造函数)属性,该属性指向 Person.
对象的constructor属性最初是用来标识对象类型的。但是,提到检测对象类型,还是 instan- ceof 操作符要更可靠一些。我们在这个例子中创建的所有对象既是Object的实例,同时也是Person 的实例。
创建自定义的构造函数意味着将来可以将它的实例标识为一种特定的类型;而这正是构造函数模式胜过工厂模式的地方。在这个例子中,person1和person2 之所以同时是Object的实例,是因为所有对象均继承自Object
2.1. 将构造函数当作函数
构造函数与其他函数的唯一区别,就在于调用它们的方式不同。不过,构造函数毕竟也是函数,不存在定义构造函数的特殊语法。任何函数,只要通过 new 操作符来调用,那它就可以作为构造函数;而任何函数,如果不通过new操作符来调用,那它跟普通函数也不会有什么两样.
2.2. 构造函数的问题
构造函数模式虽然好用,但也并非没有缺点。使用构造函数的主要问题,就是每个方法都要在每个 实例上重新创建一遍。在前面的例子中,person1 和 person2 都有一个名为sayName()的方法,但那 两个方法不是同一个Function的实例。不要忘了ECMAScript 中的函数是对象,因此每定义一个 函数,也就是实例化了一个对象。
在这个例子中,我们把sayName()函数的定义转移到了构造函数外部。而在构造函数内部,我们 将 sayName 属性设置成等于全局的sayName函数。这样一来,由于 sayName 包含的是一个指向函数 的指针,因此person1和person2对象就共享了在全局作用域中定义的同一个sayName()函数。这样做确实解决了两个函数做同一件事的问题,可是新问题又来了:在全局作用域中定义的函数实际上只能被某个对象调用,这让全局作用域有点名不副实。而更让人无法接受的是:如果对象需要定义很多方 法,那么就要定义很多个全局函数,于是我们这个自定义的引用类型就丝毫没有封装性可言了。好在, 这些问题可以通过使用原型模式来解决。
3. 原型模式
我们创建的每个函数都有一个prototype(原型)属性,这个属性是一个指针,指向一个对象,而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。如果按照字面意思来理解,那么prototype就是通过调用构造函数而创建的那个对象实例的原型对象。使用原型对象的好处是可以 让所有对象实例共享它所包含的属性和方法。换句话说,不必在构造函数中定义对象实例的信息,而是 可以将这些信息直接添加到原型对象中
3.1 理解原型对象
无论什么时候,只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个prototype属性,这个属性指向函数的原型对象。在默认情况下,所有原型对象都会自动获得一个 constructor(构造函数)属性,这个属性包含一个指向 prototype属性所在函数的指针。就拿前面的例子来说, Person.prototype.constructor指向Person。而通过这个构造函数,我们还可继续为原型对象添加其他属性和方法。
创建了自定义的构造函数之后,其原型对象默认只会取得 constructor 属性;至于其他方法,则都是从 Object 继承而来的。当调用构造函数创建一个新实例后,该实例的内部将包含一个指针(内部属性),指向构造函数的原型对象。ECMA-262第5版中管这个指针叫[[Prototype]]。虽然在脚本中 没有标准的方式访问[[Prototype]],但Firefox、Safari 和Chrome在每个对象上都支持一个属性__proto__;而在其他实现中,这个属性对脚本则是完全不可见的。不过,要明确的真正重要的一点就是,这个连接存在于实例与构造函数的原型对象之间,而不是存在于实例与构造函数之间。
以前面使用 Person 构造函数和 Person.prototype 创建实例的代码为例,下图展示了各个对 象之间的关系。
上图展示了 Person 构造函数、Person 的原型属性以及 Person 现有的两个实例之间的关系
Person.prototype指向了原型对象,而Person.prototype.constructor 又指回了Person。原型对象中除了包含constructor 属性之外,还包括后来添加的其他属性。Person的每个实例—— person1和person2都包含一个内部属性,该属性仅仅指向了 Person.prototype;换句话说,它们与构造函数没有直接的关系。此外,要格外注意的是,虽然这两个实例都不包含属性和方法,但我们却可以调用person1.sayName()。这是通过查找对象属性的过程来实现的。
原型最初只包含constructor属性,而该属性也是共享的,因此可以通过对象实例访问。
虽然可以通过对象实例访问保存在原型中的值,但却不能通过对象实例重写原型中的值。如果我们在实例中添加了一个属性,而该属性与实例原型中的一个属性同名,那我们就在实例中创建该属性,该属性将会屏蔽原型中的那个属性。
使用 hasOwnProperty()方法可以检测一个属性是存在于实例中,还是存在于原型中。这个方法(不要忘了它是从 Object 继承来的)只在给定属性存在于对象实例中时,才会返回 true。来看下面这个例子。
3.2 原型与 in 操作符
有两种方式使用 in 操作符:单独使用和在 for-in 循环中使用。在单独使用时,in操作符会在通过对象能够访问给定属性时返回true,无论该属性存在于实例中还是原型中。
3.3 更简单的原型语法
用一个包含所有属性和方法的对象字面量来重写整个原型对象.
用 instanceof 操作符测试 Object 和 Person 仍然返回 true,但 constructor 属性则 等于 Object 而不等于 Person 了。如果 constructor 的值真的很重要,可以像下面这样特意将它设 置回适当的值。
以这种方式重设 constructor 属性会导致它的[[Enumerable]]特性被设置为 true。
3.4 原型的动态性
由于在原型中查找值的过程是一次搜索,因此我们对原型对象所做的任何修改都能够立即从实例上 反映出来——即使是先创建了实例后修改原型也照样如此。
尽管可以随时为原型添加属性和方法,并且修改能够立即在所有对象实例中反映出来,但如果是重 写整个原型对象,那么情况就不一样了。我们知道,调用构造函数时会为实例添加一个指向最初原型的 [[Prototype]]指针,而把原型修改为另外一个对象就等于切断了构造函数与最初原型之间的联系。 请记住:实例中的指针仅指向原型,而不指向构造函数。
1.3 继承
许多OO语言都支持两种继承方式:接口继承和实现继承。接口继承只继承方法签名,而实现继承则继承实际的方法。如前所述,由于函数没有签名,在 ECMAScript 中无法实现接口继承。ECMAScript只支持实现继承,而且其实现继承主要是依靠原型链 来实现的。
1.3.1 原型链
ECMAScript中描述了原型链的概念,并将原型链作为实现继承的主要方法。其基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。简单回顾一下构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型 对象的内部指针。那么,假如我们让原型对象等于另一个类型的实例,结果会怎么样呢?显然,此时的 原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数 的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立,如此层层递进,就构成了实 例与原型的链条。这就是所谓原型链的基本概念。
3.1.1 别忘记默认的原型
所有引用类型默认都继承了Object,而这个继承也是通过原型链实现的。大家要记住,所有函数的默认原型都是 Object的实例,因此默认原型都会包含一个内部指针,指向Object.prototype。这也正是所有自定义类型都会继承toString()、valueOf()等默认方法的根本原因。
3.1.2 确定原型和实例的关系
可以通过两种方式来确定原型和实例之间的关系。第一种方式是使用 instanceof 操作符,只要用 这个操作符来测试实例与原型链中出现过的构造函数,结果就会返回true。以下几行代码就说明了这 一点。
第二种方式是使用isPrototypeOf() 方法。同样,只要是原型链中出现过的原型,都可以说是该 原型链所派生的实例的原型,因此 isPrototypeOf()方法也会返回 true
3.1.3 原型链的问题
原型链虽然很强大,可以用它来实现继承,但它也存在一些问题。其中,最主要的问题来自包含引 用类型值的原型。想必大家还记得,我们前面介绍过包含引用类型值的原型属性会被所有实例共享;而 这也正是为什么要在构造函数中,而不是在原型对象中定义属性的原因。在通过原型来实现继承时,原 型实际上会变成另一个类型的实例。于是,原先的实例属性也就顺理成章地变成了现在的原型属性了。
1.3.1 借用构造函数
有时也叫伪造对象或经典继承,这种技术的基本思想相当简单,即在子类型构造函数的内部调用超类型构造函数。别忘了,函数只不过是在特定环境中执行代码的对象, 因此通过使用apply()和call()方法也可以在(将来)新创建的对象上执行构造函数
代码中加粗的那一行代码“借调”了超类型的构造函数。通过使用 call()方法(或apply()方法也可以),我们实际上是在(未来将要)新创建的 SubType 实例的环境下调用了 SuperType 构造函数。这样一来,就会在新SubType对象上执行 SuperType()函数中定义的所有对象初始化代码。结果, SubType 的每个实例就都会具有自己的colors属性的副本了。
- 传递参数
相对于原型链而言,借用构造函数有一个很大的优势,即可以在子类型构造函数中向超类型构造函数传递参数。
以上代码中的 SuperType只接受一个参数name,该参数会直接赋给一个属性。在SubType构造函数内部调用SuperType 构造函数时,实际上是为 SubType 的实例设置了 name 属性。为了确保SuperType构造函数不会重写子类型的属性,可以在调用超类型构造函数后,再添加应该在子类型中 定义的属性。
- 借用构造函数的问题
如果仅仅是借用构造函数,那么也将无法避免构造函数模式存在的问题——方法都在构造函数中定义,因此函数复用就无从谈起了。而且,在超类型的原型中定义的方法,对子类型而言也是不可见的,结果所有类型都只能使用构造函数模式。考虑到这些问题,借用构造函数的技术也是很少单独使用的。
1.3.2 组合继承
组合继承,有时候也叫做伪经典继承,指的是将原型链和借用构造函数的技术组合到一块,从而发挥二者之长的一种继承模式。其背后的思路是使用原型链实现对原型属性和方 法的继承,而通过借用构造函数来实现对实例属性的继承。这样,既通过在原型上定义方法实现了函数复用,又能够保证每个实例都有它自己的属性。
SuperType 构造函数定义了两个属性:name 和 colors。SuperType 的原型定义 了一个方法 sayName()。SubType 构造函数在调用 SuperType 构造函数时传入了 name 参数,紧接着 又定义了它自己的属性 age。然后,将 SuperType 的实例赋值给 SubType 的原型,然后又在该新原型 上定义了方法 sayAge()。这样一来,就可以让两个不同的 SubType 实例既分别拥有自己属性——包括 colors 属性,又可以使用相同的方法了。
组合继承避免了原型链和借用构造函数的缺陷,融合了它们的优点,成为 JavaScript 中最常用的继 承模式。而且,instanceof 和 isPrototypeOf()也能够用于识别基于组合继承创建的对象。
1.3.3 原型式继承(略)
1.3.4 寄生式继承(略)
1.3.5 寄生组合式继承
前面说过,组合继承是JavaScript最常用的继承模式;不过,它也有自己的不足。组合继承最大的问题就是无论什么情况下,都会调用两次超类型构造函数:一次是在创建子类型原型的时候,另一次是 在子类型构造函数内部。没错,子类型最终会包含超类型对象的全部实例属性,但我们不得不在调用子 类型构造函数时重写这些属性。
在第一次调用SuperType构造函数时,SubType.prototype 会得到两个属性:name和colors;它们都是 SuperType 的实例属性,只不过 现在位于SubType 的原型中。当调用SubType构造函数时,又会调用一次 SuperType构造函数,这一次又在新对象上创建了实例属性name和colors。于是,这两个属性就屏蔽了原型中的两个同名属性.
所谓寄生组合式继承,即通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。其背后的基本思路是:不必为了指定子类型的原型而调用超类型的构造函数,我们所需要的无非就是超类型 原型的一个副本而已。本质上,就是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。
这个示例中的 inheritPrototype()函数实现了寄生组合式继承的最简单形式。这个函数接收两 个参数:子类型构造函数和超类型构造函数。在函数内部,第一步是创建超类型原型的一个副本。第二 步是为创建的副本添加 constructor 属性,从而弥补因重写原型而失去的默认的 constructor 属性。 最后一步,将新创建的对象(即副本)赋值给子类型的原型。这样,我们就可以用调用 inherit- Prototype()函数的语句,去替换前面例子中为子类型原型赋值的语句了.
这个例子的高效率体现在它只调用了一次 SuperType 构造函数,并且因此避免了在 SubType.prototype上面创建不必要的、多余的属性。与此同时,原型链还能保持不变;因此,还能够正常使用 instanceof 和 isPrototypeOf()。开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。
小结
ECMAScript 支持面向对象(OO)编程,但不使用类或者接口。对象可以在代码执行过程中创建和增强,因此具有动态性而非严格定义的实体。在没有类的情况下,可以采用下列模式创建对象。
- 工厂模式,使用简单的函数创建对象,为对象添加属性和方法,然后返回对象。这个模式后来 被构造函数模式所取代。
- 构造函数模式,可以创建自定义引用类型,可以像创建内置对象实例一样使用 new 操作符。不 过,构造函数模式也有缺点,即它的每个成员都无法得到复用,包括函数。由于函数可以不局 限于任何对象(即与对象具有松散耦合的特点),因此没有理由不在多个对象间共享函数。
- 原型模式,使用构造函数的prototype属性来指定那些应该共享的属性和方法。组合使用构造 函数模式和原型模式时,使用构造函数定义实例属性,而使用原型定义共享的属性和方法。
JavaScript 主要通过原型链实现继承。原型链的构建是通过将一个类型的实例赋值给另一个构造函
数的原型实现的。这样,子类型就能够访问超类型的所有属性和方法,这一点与基于类的继承很相似。 原型链的问题是对象实例共享所有继承的属性和方法,因此不适宜单独使用。解决这个问题的技术是借 用构造函数,即在子类型构造函数的内部调用超类型构造函数。这样就可以做到每个实例都具有自己的 属性,同时还能保证只使用构造函数模式来定义类型。使用最多的继承模式是组合继承,这种模式使用 原型链继承共享的属性和方法,而通过借用构造函数继承实例属性。
此外,还存在下列可供选择的继承模式。
- 原型式继承,可以在不必预先定义构造函数的情况下实现继承,其本质是执行对给定对象的浅复制。而复制得到的副本还可以得到进一步改造。
- 寄生式继承,与原型式继承非常相似,也是基于某个对象或某些信息创建一个对象,然后增强对象,最后返回对象。为了解决组合继承模式由于多次调用超类型构造函数而导致的低效率问题,可以将这个模式与组合继承一起使用。
- 寄生组合式继承,集寄生式继承和组合继承的优点与一身,是实现基于类型继承的最有效方式。